Documentation Index
Fetch the complete documentation index at: https://mintlify.com/santiagodc8/tu_perfil.net/llms.txt
Use this file to discover all available pages before exploring further.
TuPerfil.net uses Supabase Auth for all authentication. Sessions are stored in HTTP-only cookies and refreshed automatically by Next.js middleware on every request to a protected route.
Auth provider
Supabase Auth issues JWTs and manages sessions. The application does not implement its own password hashing, token generation, or session storage — all of that is handled by Supabase and surfaced through the @supabase/ssr package.
User roles
Two roles are supported, defined as a union type in src/types/index.ts:
export type UserRole = 'admin' | 'editor';
export interface Profile {
id: string;
email: string;
full_name: string;
role: UserRole;
created_at: string;
updated_at: string;
}
Roles are stored in the profiles table, not in the Supabase JWT claims. Every user in auth.users gets a corresponding row in profiles created automatically by the handle_new_user trigger:
supabase/migrations/012_user_roles.sql
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO profiles (id, email, full_name, role)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'full_name', ''),
-- The first user created is admin; all subsequent users are editors
CASE
WHEN (SELECT COUNT(*) FROM profiles) = 0 THEN 'admin'
ELSE 'editor'
END
);
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION handle_new_user();
The first user created in a fresh Supabase project automatically receives the admin role. Every additional user starts as editor. You can promote an editor to admin by updating the role column in the profiles table from the Supabase dashboard.
Creating the first admin user
There is no public registration form. You create users directly in the Supabase dashboard:
- Open your Supabase project.
- Go to Authentication → Users.
- Click Add user and enter an email and password.
- The
handle_new_user trigger fires immediately and creates a profiles row. If this is the first user, role is set to admin.
How middleware protects /admin routes
The file src/middleware.ts registers a single middleware function that runs on every request matching /admin/:path*:
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: ["/admin/:path*"],
};
The updateSession function in src/lib/supabase/middleware.ts does three things:
- Creates a short-lived Supabase server client that can read and write request/response cookies.
- Calls
supabase.auth.getUser() to validate the session cookie. This makes a network call to Supabase on every matched request.
- Applies redirect rules based on the result:
src/lib/supabase/middleware.ts
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
// Protect /admin routes (except /admin/login)
if (
!user &&
request.nextUrl.pathname.startsWith("/admin") &&
!request.nextUrl.pathname.startsWith("/admin/login")
) {
const url = request.nextUrl.clone();
url.pathname = "/admin/login";
return NextResponse.redirect(url);
}
// If already logged in and visiting /admin/login, redirect to dashboard
if (user && request.nextUrl.pathname === "/admin/login") {
const url = request.nextUrl.clone();
url.pathname = "/admin";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
Redirect logic summary
| Condition | Result |
|---|
No session + request to /admin/* (not /admin/login) | Redirect to /admin/login |
Active session + request to /admin/login | Redirect to /admin |
| Any other case | Pass request through unchanged |
How the server-side Supabase client reads sessions
The server client in src/lib/supabase/server.ts uses @supabase/ssr to integrate with Next.js cookie storage:
src/lib/supabase/server.ts
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Safe to ignore when called from a Server Component.
}
},
},
}
);
}
The try/catch in setAll suppresses an error that Next.js throws when a Server Component tries to set a cookie. The middleware has already refreshed the session at the edge, so this is safe to ignore.
Auth helper functions
Two helper functions in src/lib/auth.ts make it easy to look up the current user’s role or full profile from any Server Component or API route:
import { createClient } from "@/lib/supabase/server";
import type { UserRole } from "@/types";
/**
* Returns the role of the currently authenticated user,
* or null if there is no active session.
*/
export async function getCurrentUserRole(): Promise<UserRole | null> {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
return (profile?.role as UserRole) ?? null;
}
/**
* Returns the full profile of the currently authenticated user,
* or null if there is no active session.
*/
export async function getCurrentProfile() {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
return profile ?? null;
}
For operations that should be restricted to admins only (such as managing users or changing roles), server code calls getCurrentUserRole() and checks the result:
const role = await getCurrentUserRole();
if (role !== 'admin') {
return new Response('Forbidden', { status: 403 });
}
Database-level is_admin() helper
The profiles RLS policies use a PostgreSQL helper function to enforce role-based access at the database level:
CREATE OR REPLACE FUNCTION is_admin()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1
FROM profiles
WHERE id = auth.uid()
AND role = 'admin'
);
$$;
This function is used in the profiles table RLS policies:
-- Only admins can update profiles
CREATE POLICY "profiles_admin_update"
ON profiles FOR UPDATE
TO authenticated
USING (is_admin())
WITH CHECK (is_admin());
-- Only admins can delete profiles
CREATE POLICY "profiles_admin_delete"
ON profiles FOR DELETE
TO authenticated
USING (is_admin());
RLS access summary
This table summarizes what anonymous versus authenticated users can do across the most sensitive tables:
| Table | Anonymous | Authenticated (editor) | Authenticated (admin) |
|---|
articles | Read published, non-deleted | Read all + write | Read all + write |
categories | Read | Read + write | Read + write |
contacts | Insert only | Read + update + delete | Read + update + delete |
comments | Insert + read approved | Read all + moderate | Read all + moderate |
subscribers | Insert (subscribe) | Read + manage | Read + manage |
profiles | None | Read only | Read + update + delete |
ads | Read active | Read + write | Read + write |
page_views | Insert only | Read | Read |
ad_events | Insert only | Read | Read |
breaking_news | Read | Read + write | Read + write |
The admin client (createAdminClient() in src/lib/supabase/admin.ts) uses the SUPABASE_SERVICE_ROLE_KEY and bypasses all RLS policies. Use it only in server-side API routes and never expose it to the browser.